﻿using System;
using System.ComponentModel;
using System.Threading;
using System.Collections.Generic;
using CannedHeater.Model.Utilities.Pachube;
using System.Linq;

namespace CannedHeater.Model
{
    /// <summary>
    /// Responsible for fetching data from Pachube, and storing theese
    /// </summary>
    public class DataFetcher : INotifyPropertyChanged
    {
        #region Locally used variables
        /// <summary>
        /// Represents the history of data from pachube
        /// </summary>
        private DataHistory _dh;
        /// <summary>
        /// Timer for autofetching data from pachube
        /// </summary>
        private Timer _autoFetchTimer = null;
        /// <summary>
        /// ... in ms (default 3 min)
        /// </summary>
        private const int AUTOFETCH_INTERVAL = 180000;
        /// <summary>
        /// The maximum amount of data that can be fetched at first fetch
        /// </summary>
        private const int MAX_FIRST_FETCH_DAYS = 30;
        /// <summary>
        /// The new times for which an attempt has been made to fetch data
        /// </summary>
        /// <remarks>Necessary because of async calls</remarks>
        private List<DateTime> _extraFetchTimes;
        #endregion

        #region Types
        /// <summary>
        /// The fetched data, or an error, is "returned" using these event args
        /// </summary>
        public class DataFetchCompletedEventArgs : EventArgs
        {
            public enum StatusType
            {
                Success, Error
            }

            public StatusType Status { get; private set; }
            public string Msg { get; private set; }

            public DataFetchCompletedEventArgs(StatusType status)
            {
                Status = status;
            }

            public DataFetchCompletedEventArgs(StatusType status, string msg)
            {
                Status = status;
                Msg = msg;
            }
        }
        #endregion

        #region Events
        public delegate void DataFetchCompletedEventHandler(object sender, DataFetchCompletedEventArgs args);

        /// <summary>
        /// Raised when data has been fetched, or an error occured
        /// </summary>
        public event DataFetchCompletedEventHandler DataFetchCompleted;
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion

        #region Properties
        /// <summary>
        /// Represents the history of data from pachube
        /// </summary>
        public DataHistory DH
        {
            get { return _dh; }
            private set
            {
                _dh = value;
                RaisePropertyChanged("DH");
            }
        }

        private bool _autoFetch = false;
        /// <summary>
        /// Start/Stop autofetching
        /// </summary>
        public bool AutoFetch
        {
            get { return _autoFetch; }
            set
            {
                if (_autoFetch == false && value == true) StartAutoFetch();
                else if (value == false) StopAutoFetch();
                _autoFetch = value;
            }
        }

        private DateTime _lastFetched;
        /// <summary>
        /// The last time data has been fetched
        /// </summary>
        public DateTime LastFetched
        {
            get
            {
                DateTime oldest = DateTime.Now.Subtract(TimeSpan.FromDays(MAX_FIRST_FETCH_DAYS));
                if (_lastFetched < oldest) return oldest; // The is a lower limit for which new data should be fetched - for performance issues
                return _lastFetched;
            }
            private set { _lastFetched = value; }
        }

        /// <summary>
        /// Whether or not data has ever been fetched
        /// </summary>
        public bool HasEverFetched
        {
            get;
            private set;
        }

        /// <summary>
        /// The times at which data has been fetched
        /// </summary>
        public List<DateTime> FetchedTimes
        {
            get;
            private set;
        }
        #endregion

        #region Constructors
        /// <summary>
        /// Construct a new datafetcher, for manual and/or automatic fetching data from pachube
        /// </summary>
        public DataFetcher()
        {
            DH = new DataHistory();
            HasEverFetched = false;
            FetchedTimes = new List<DateTime>();
        }

        /// <summary>
        /// Restore a DataFetcher object
        /// </summary>
        /// <param name="dh">The old datahistory to be used for restoring the datafetcher</param>
        /// <param name="lastFetched">The old lastFetched time - the latest time at which data has been fetched previously</param>
        /// <param name="fetchedTimes">The old times at which data has been fetched</param>
        public DataFetcher(DataHistory dh, DateTime lastFetched, List<DateTime> fetchedTimes)
        {
            DH = dh;
            LastFetched = lastFetched;
            FetchedTimes = fetchedTimes;
            HasEverFetched = true; // No reason to restore unless data has been previously fetched
        }
        #endregion

        /// <summary>
        /// Starts auto fetching data with intervals <see cref="AUTOFETCH_INTERVAL"/>, first fetch after <see cref="AUTOFETCH_INTERVAL"/>
        /// </summary>
        private void StartAutoFetch()
        {
            if (_autoFetchTimer != null) return;
            _autoFetchTimer = new Timer(new TimerCallback(DoAutoFetch), null, AUTOFETCH_INTERVAL, AUTOFETCH_INTERVAL);
        }

        /// <summary>
        /// Stops auto fetching data
        /// </summary>
        private void StopAutoFetch()
        {
            if (_autoFetchTimer == null) return;
            _autoFetchTimer.Dispose();
            _autoFetchTimer = null;
        }

        /// <summary>
        /// The automatic fetch procedure
        /// </summary>
        /// <param name="o">State provided for <see cref="TimerCallback"/></param>
        private void DoAutoFetch(object o)
        {
            PachubeDocReader res =
                    new PachubeDocReader(
                        new PachubeRequest(
                            Settings.GlobalSettings.PACHUBE_API_KEY,
                            Settings.GlobalSettings.PACHUBE_FEED_ID,
                            "30minutes", 180)); // No need to fetch a lot of data, afterall fetching with (expected) low time interval
            res.PachubeDataParsed += new PachubeDocReader.PachubeDataParsedHandler(res_PachubeDataParsed);

            // Get the times since last fetch for which data is now being autofetched.
            // Added on successfull result from pachube - see res_PachubeDataParsed
            _extraFetchTimes = TimesInInterval(NearestQuarterTime(LastFetched, false),
                                               NearestQuarterTime(DateTime.Now, false), TimeSpan.FromMinutes(15));

            res.ExecPachubeRequest(); // Start request
        }

        /// <summary>
        /// Clean the data that is currently 'stored' within the datafetcher (also DataHistory object).
        /// Removes all data older than <paramref name="ts"/> (from current time)
        /// </summary>
        /// <param name="ts">The time span (from current time) for which data should be kept</param>
        public void Clean(TimeSpan ts)
        {
            DateTime oldestDataTimeToKeep = DateTime.Now.Subtract(ts);

            // Clean FetchedTimes by only saving data newer than oldestDataTimeToKeep
            List<DateTime> newFetchedTimes = new List<DateTime>();
            foreach ( DateTime dt in FetchedTimes)
            {
                if (dt >= oldestDataTimeToKeep) newFetchedTimes.Add(dt);
            }
            FetchedTimes = newFetchedTimes;

            // Clean DataHistory object
            _dh.IndoorTempData = CleanDataArray(ts, _dh.IndoorTempData);
            _dh.OutdoorTempData = CleanDataArray(ts, _dh.OutdoorTempData);
            _dh.SolarHeaterTempData = CleanDataArray(ts, _dh.SolarHeaterTempData);
            _dh.AirFlowData = CleanDataArray(ts, _dh.AirFlowData);

        }

        /// <summary>
        /// Fetches data on demand, latest 3 hours of pachube data, interval of data retrieved is 3 minutes.
        /// Used primarily for initial load after program startup.
        /// To load more, <see cref="FetchDataIfUnavailable"/>
        /// </summary>
        public void FetchData()
        {
            PachubeDocReader res =
                    new PachubeDocReader(
                        new PachubeRequest(
                            Settings.GlobalSettings.PACHUBE_API_KEY,
                            Settings.GlobalSettings.PACHUBE_FEED_ID,
                            "3hours",
                            180)); // Initial load, small interval
            res.PachubeDataParsed += new PachubeDocReader.PachubeDataParsedHandler(res_PachubeDataParsed);

            // Prepare new fetched times to add to FetchedTimes
            // Maximum fetched latest 3 hours
            DateTime fromTime = LastFetched;
            DateTime time3HoursAgo = DateTime.Now.Subtract(TimeSpan.FromHours(3));
            if (LastFetched < time3HoursAgo)
                fromTime = time3HoursAgo;

            // Get new FetchedTimes, added on success - see res_PachubeDataParsed
            _extraFetchTimes = TimesInInterval(NearestQuarterTime(fromTime, false),
                                               NearestQuarterTime(DateTime.Now, false), TimeSpan.FromMinutes(15));

            res.ExecPachubeRequest();
        }

        // 
        // 
        /// <summary>
        /// Fetches data for time span <paramref name="ts"/> unless it is already available.
        /// Ensures data fetched for around every 3rd min in latest 3 hours
        /// Ensures data fetched for around every 15 min in latest 24 hrs.
        /// Ensures data fetched for around every hour in latest 30 days
        /// </summary>
        /// <param name="ts">The time span (from current time) to fetch data for</param>
        /// <returns>True if all data is already available, false otherwise</returns>
        public bool FetchDataIfUnavailable(TimeSpan ts)
        {
            // Can most likely be improved in performance!

            DateTime lastTime = DateTime.Now; // Searches from latest time and 'down'
            DateTime currentTime = DateTime.Now;

            DateTime lastExpectedTime = NearestQuarterTime(currentTime.Subtract(ts), true);

            _extraFetchTimes = new List<DateTime>();

            // Sort fetched times, latest first, to loop through newest first
            FetchedTimes.Sort(delegate(DateTime d1, DateTime d2) { return d2.CompareTo(d1); });

            bool ok = true; // Whether or not data is already available so far
            bool complete3Hours = false; // Whether or not we are done checking for the first 3 hours
            bool complete24Hours = false; // ... 24 hours

            // Check all FetchedTimes, look for missing times, if found, fetch data
            foreach ( DateTime dt in FetchedTimes )
            {
                TimeSpan diff = lastTime.Subtract(dt); // Difference from previous time seen in FetchedTimes (or initially current time)
                if ( !complete3Hours)
                {
                    // Within first 3 hours

                    if (diff.TotalMinutes >= 4) // Need around 3 minutes interval (allow tiny differences)
                    {
                        // Missing time
                        ok = false;
                        // Prepare to add missing times to FetchedTimes on success
                        _extraFetchTimes.AddRange(TimesInInterval(dt, lastTime, new TimeSpan(0, 3, 0)));
                    }
                    if (currentTime.Subtract(dt).TotalHours >= 3) complete3Hours = true;
                }
                else if (!complete24Hours)
                {
                    // Within first 24 hours
                    // Similar with 3 hours check

                    if (diff.TotalMinutes >= 16)
                    {
                        ok = false;
                        _extraFetchTimes.AddRange(TimesInInterval(dt, lastTime, new TimeSpan(0, 15, 0)));
                    }
                    if (currentTime.Subtract(dt).TotalDays >= 1) complete24Hours = true;
                }
                else
                {
                    // After first 24 hours
                    // Similar with 3 hours check

                    if ( diff.TotalMinutes >= 65 )
                    {
                        ok = false;
                        _extraFetchTimes.AddRange(TimesInInterval(dt, lastTime, new TimeSpan(1, 0, 0)));
                    }
                }

                lastTime = dt; // Remember last seen time
                if (dt < lastExpectedTime) break; // No need to search through more than the timespan ts
            }

            // Check last time is sufficient (allow tiny difference)
            if ( lastTime.Subtract(TimeSpan.FromMinutes(5)) > lastExpectedTime )
            {
                ok = false;
                if (currentTime.Subtract(lastTime).TotalDays >= 1)
                {
                    // Add missing times, all after 24 hours
                    _extraFetchTimes.AddRange(TimesInInterval(lastExpectedTime, lastTime, new TimeSpan(1, 0, 0)));
                }
                else
                {
                    // Add missing times, up to and after 24 hours
                    _extraFetchTimes.AddRange(TimesInInterval(currentTime.Subtract(TimeSpan.FromHours(24)), lastTime, new TimeSpan(0, 15, 0)));
                    _extraFetchTimes.AddRange(TimesInInterval(lastExpectedTime, currentTime.Subtract(TimeSpan.FromHours(24)), new TimeSpan(1, 0, 0)));
                }
            }

            // Convert time span to pachube compatible time duration parameter
            string time = TimeSpanToPachubeTimeString(ts);


            int interval = 180; // Interval of 3 minutes to fetch missing data
            if (ts.TotalHours > 3) interval = 900; // Interval of 15 minutes if more than 3 hours
            if (ts.TotalDays > 5) interval = 2700; // Pachube can only give max one data measure every 45 min for duration this large.
            if (ts.TotalDays > 7) interval = 10800; // Pachube can only give max one data measure every 3 hour for duration this large.

            // If times for which data is not already fetched was found, start fetching missing data
            if (!ok)
            {
                PachubeDocReader res =
                    new PachubeDocReader(
                        new PachubeRequest(
                            Settings.GlobalSettings.PACHUBE_API_KEY,
                            Settings.GlobalSettings.PACHUBE_FEED_ID,
                            time, interval));
                res.PachubeDataParsed += new PachubeDocReader.PachubeDataParsedHandler(res_PachubeDataParsed);
                res.ExecPachubeRequest();
            }

            // Return whether or not all data in requested timespan has already been fetched (if false, async fetch has been started)
            return ok;
        }

        /// <summary>
        /// Handles the result of data parsed with <see cref="PachubeDocReader"/>
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="args"></param>
        private void res_PachubeDataParsed(object sender, PachubeDocReader.PachubeDataParsedArgs args)
        {
            if (args.Status == PachubeDocReader.StatusType.Success)
            {
                // Add the new fetched times - if none exists, just add current time
                if (_extraFetchTimes == null || _extraFetchTimes.Count == 0) FetchedTimes.Add(DateTime.Now);
                else
                {
                    foreach (DateTime dt in _extraFetchTimes)
                    {
                        if (!FetchedTimes.Contains(dt))
                            FetchedTimes.Add(dt);
                    }
                }

                LastFetched = DateTime.Now; // Update the time for which data has been fetched

                // Load a new DateHistory based on the parsed document from pachube
                DataHistory parsedData = DataHistory.LoadHistoryFromPachubeData(args.Data, "99811", "100831", "179961", "179963");
                //DataHistory parsedData = DataHistory.LoadHistoryFromPachubeData(args.Data, "OutsiteTemp", "OutsiteTemp", "SolarHeaterTemp", "AirFlow");

                // Merge the new data with the existing data. In case of duplicate, keep the newest data.
                parsedData.IndoorTempData = MergeKeyValuePairArraysNoDuplicate(parsedData.IndoorTempData, DH.IndoorTempData);
                parsedData.OutdoorTempData = MergeKeyValuePairArraysNoDuplicate(parsedData.OutdoorTempData, DH.OutdoorTempData);
                parsedData.SolarHeaterTempData = MergeKeyValuePairArraysNoDuplicate(parsedData.SolarHeaterTempData, DH.SolarHeaterTempData);
                parsedData.AirFlowData = MergeKeyValuePairArraysNoDuplicate(parsedData.AirFlowData, DH.AirFlowData);

                // Update the datahistory, and now data has definately been fetched
                DH = parsedData;
                HasEverFetched = true;
             
                // Report result
                RaiseDataFetchCompleted(new DataFetchCompletedEventArgs(DataFetchCompletedEventArgs.StatusType.Success));
            }
            else
            {
                // If error for retrieving pachube document, forward this internal error, otherwise forward the local document parsing error
                if ( args.Status == PachubeDocReader.StatusType.PachubeError)
                    RaiseDataFetchCompleted(new DataFetchCompletedEventArgs(DataFetchCompletedEventArgs.StatusType.Error, args.PachubeError.Status.ToString() + ": " + args.PachubeError.Error));
                else
                    RaiseDataFetchCompleted(new DataFetchCompletedEventArgs(DataFetchCompletedEventArgs.StatusType.Error, args.Status.ToString() + ": " + args.ErrorMsg));
            }
        }


        #region Helpers
        /// <summary>
        /// Converts a timespan to the time notation used for the duration parameter for pachube.
        /// Only supports days and hours (one or the other) - default fallback is 3 hours
        /// </summary>
        /// <param name="ts">Timespan to convert</param>
        /// <returns>A string representatin of the time, pachube compatible</returns>
        /// <remarks>Only supports days and hours (one or the other) - default fallback is 3 hours</remarks>
        private string TimeSpanToPachubeTimeString(TimeSpan ts)
        {
            if (ts.TotalDays == 1) return "1day";
            else if (ts.TotalDays != 0) return ((int)ts.TotalDays).ToString() + "days";
            else if (ts.TotalHours == 1) return "1hour";
            else if (ts.TotalHours != 0) return ((int)ts.TotalHours).ToString() + "hours";
            else return "3hours"; // Default fallback
        }

        /// <summary>
        /// Converts a datetime to the nearest quarter
        /// </summary>
        /// <param name="dt">The datetime to convert</param>
        /// <param name="roundUp">
        /// Whether or not datetime shold be "rounded up or down".
        /// Round up: x:01 -> x:15, x:15 -> x:15.
        /// Round down: x:00 -> x:00, x:14 -> x:00.
        /// </param>
        /// <returns>
        /// DateTime representing nearest quarter to <paramref name="dt"/>.
        /// Round up: x:01 -> x:15, x:15 -> x:15.
        /// Round down: x:00 -> x:00, x:14 -> x:00
        /// </returns>
        private DateTime NearestQuarterTime(DateTime dt, bool roundUp)
        {
            if (roundUp)
            {
                if (dt.Minute > 0 && dt.Minute <= 15)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 15, 0);
                else if (dt.Minute <= 30)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 30, 0);
                else if (dt.Minute <= 45)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 45, 0);
                else
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour + 1, 0, 0);
            }
            else
            {
                if (dt.Minute >= 0 && dt.Minute < 15)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0);
                else if (dt.Minute < 30)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 15, 0);
                else if (dt.Minute < 45)
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 30, 0);
                else
                    return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 45, 0);
            }
        }

        /// <summary>
        /// Creates a list with all DateTimes inbetween <paramref name="start"/> and <paramref name="end"/> time, with the interval <paramref name="interval"/>. Both inclusive.
        /// </summary>
        /// <param name="start">The inclusive start time</param>
        /// <param name="end">The inclusive end time</param>
        /// <param name="interval">The interval at which datetimes should be added to the result</param>
        /// <returns>A list of all DateTimes inbetween <paramref name="start"/> and <paramref name="end"/> time, with the interval <paramref name="interval"/>. Both inclusive.</returns>
        /// <remarks>Input times are converted to nearest quarter, <paramref name="start"/> rounded down, <paramref name="end"/> rounded up</remarks>
        private List<DateTime> TimesInInterval(DateTime start, DateTime end, TimeSpan interval)
        {
            // Nearest quarter:
            start = NearestQuarterTime(start, false);
            end = NearestQuarterTime(end, true);

            List<DateTime> l = new List<DateTime>();
            DateTime lastTime = start;
            l.Add(start); // Start time inclusive
            while (true)
            {
                DateTime newTime = lastTime.Add(interval);
                if (newTime > end) break;

                l.Add(newTime);
                lastTime = newTime;
            }

            return l;
        }

        /// <summary>
        /// Merges to arrays of type <code>SerializableKeyValuePair&lt;DateTime, double&gt;</code>.
        /// Dulicates eliminated
        /// </summary>
        /// <param name="arr">First array to merge</param>
        /// <param name="arr2">Second array to merge, values that are already present in first array is not included in result</param>
        /// <returns>Merged array</returns>
        private SerializableKeyValuePair<DateTime, double>[] MergeKeyValuePairArraysNoDuplicate(SerializableKeyValuePair<DateTime, double>[] arr, SerializableKeyValuePair<DateTime, double>[] arr2)
        {
            if (arr2 == null) return arr;

            List<SerializableKeyValuePair<DateTime, double>> l = arr.ToList();
            foreach (SerializableKeyValuePair<DateTime, double> kvp in arr2)
            {
                if (!l.Contains(kvp))
                {
                    l.Add(kvp);
                }
            }
            return l.ToArray();
        }

        /// <summary>
        /// Cleans the <paramref name="arr"/> array, removing data older than <paramref name="ts"/> (from current time)
        /// </summary>
        /// <param name="ts">The time span (from current time) for which data should be kept</param>
        /// <param name="arr">The array to clean</param>
        /// <returns>Cleaned array</returns>
        private SerializableKeyValuePair<DateTime, double>[] CleanDataArray(TimeSpan ts, SerializableKeyValuePair<DateTime, double>[] arr)
        {
            if (arr == null) return null;

            DateTime oldestDataTimeToKeep = DateTime.Now.Subtract(ts);

            List<SerializableKeyValuePair<DateTime, double>> newArr =
                new List<SerializableKeyValuePair<DateTime, double>>();

            foreach (SerializableKeyValuePair<DateTime, double> kvp in arr)
            {
                if (kvp.Key >= oldestDataTimeToKeep) newArr.Add(kvp);
            }

            return newArr.ToArray();
        }

        private void RaiseDataFetchCompleted(DataFetchCompletedEventArgs args)
        {
            if (DataFetchCompleted != null)
                DataFetchCompleted(this, args);
        }

        public void RaisePropertyChanged(string prop)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(prop));
        }
        #endregion
    }
}
